iT邦幫忙

1

多媒体付费资源网站架构设计测试

  • 分享至 

  • xImage
  •  

提示:这是基于对付费媒体资源网站的架构分析,总结笔记后,最后由ai完善的.只是设计方案,没有实践过,不要直接拿到公司使用

技术栈:Vue 3 + JavaScript / Gin (Go) / MySQL / Redis / AWS S3
版本:v1.0
日期:2026-09-13


1. 项目目标

本项目建设一个图片与视频付费资源网站。未购买用户只允许查看图片缩略图/水印图和几秒钟的视频预览;用户支付成功后,刷新或重新进入页面,后端重新查询购买状态,允许查看高清图片或播放完整视频。

安全控制不依赖 Vue 的 v-if。Vue 只负责界面状态;真正的资源权限由 Gin + MySQL + Redis + AWS S3 决定。

浏览器只要已经显示图片或播放视频,就不能从技术上绝对阻止截图、录屏或抓取已下发数据。本方案的目标是:未购买用户拿不到高清原图、完整视频、AES Key 或长期有效的私有资源地址。


2. 技术栈

技术 职责
前端 Vue 3 + JavaScript 页面、预览、支付后刷新、高清查看、hls.js 播放、错误提示
后端 Gin + Go JWT、购买鉴权、Play Token、m3u8 重写、AES Key API、S3 Presign
数据库 MySQL 用户、媒体、订单、购买授权、AES Key 元数据
缓存 Redis JWT Token 和视频 Play Token,TTL 均为 6 小时
对象存储 AWS S3 缩略图、预览视频、高清图片、m3u8、.ts 分片
加速 S3 Transfer Acceleration 使用 AWS 默认加速端点
播放器 hls.js 加载 HLS、请求 Key/Segment,交给浏览器媒体栈解密播放

2.1 S3 Transfer Acceleration

启用 Transfer Acceleration 后使用:

https://<bucket-name>.s3-accelerate.amazonaws.com/<object-key>

Bucket 需要显式启用 Transfer Acceleration;Bucket 名称不能包含句点 .。私有对象不设置公开读,Gin 使用 AWS SDK for Go v2 生成短期 Presigned GET URL。


3. Web 总体架构图

flowchart LR
    U[浏览器 / Vue3] -->|HTTPS API| G[Gin API]
    G --> M[(MySQL)]
    G --> R[(Redis)]
    G -->|AWS SDK for Go v2| S[(AWS S3 Private Bucket)]
    U -->|302 后的短期 Presigned URL| S
    P[支付平台] -->|Webhook| G

3.1 核心架构原则

  1. S3 Bucket 默认私有。
  2. 前端 v-if 只控制界面,不作为权限判断。
  3. 是否已购买以 MySQL 中的授权记录为准。
  4. JWT 和 Play Token 都存 Redis,TTL 为 21600 秒。
  5. 高清图片采用短期 S3 Presigned URL。
  6. 完整视频采用 HLS + AES-128。
  7. m3u8、AES Key 和每个视频分片都要求有效 Play Token。
  8. Gin 不代理全部视频字节;分片鉴权成功后使用 HTTP 302 跳转到短期 S3 Presigned URL。
  9. AES Key 由 Gin 鉴权后返回,不能作为公开 S3 对象暴露。

4. AWS S3 目录设计

s3://paid-media-bucket/
|-- preview/
|   |-- image/<media-id>/thumbnail.webp
|   `-- video/<media-id>/preview-8s.mp4
|-- original/
|   `-- image/<media-id>/original.jpg
`-- hls/
    `-- <media-id>/
        |-- master.m3u8
        |-- index_1080p.m3u8
        |-- seg_00001.ts
        |-- seg_00002.ts
        `-- ...

4.1 预览资源

图片预览建议为低分辨率或带水印版本;视频预览建议单独生成 5~10 秒 MP4。预览资源也可以保持私有,通过短期 Presigned URL 返回。

4.2 付费资源

高清图片和完整 HLS 视频全部放在私有对象路径中,不允许匿名访问。


5. MySQL 五张核心表

本方案使用:

  1. users
  2. media
  3. orders
  4. user_media_purchase
  5. aes_key_table

其中 orders 保存交易历史,user_media_purchase 保存当前访问授权。这样支付失败、重复支付、退款等订单历史不会影响权限查询。

5.1 users

CREATE TABLE users (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(64) NOT NULL,
    email VARCHAR(255) NULL,
    password_hash VARCHAR(255) NOT NULL,
    status TINYINT NOT NULL DEFAULT 1,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_users_username (username),
    UNIQUE KEY uk_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

5.2 media

CREATE TABLE media (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(255) NOT NULL,
    media_type ENUM('IMAGE','VIDEO') NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    preview_object_key VARCHAR(512) NOT NULL,
    original_object_key VARCHAR(512) NULL,
    hls_master_object_key VARCHAR(512) NULL,
    preview_seconds INT NULL,
    status ENUM('DRAFT','ONLINE','OFFLINE') NOT NULL DEFAULT 'DRAFT',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP,
    KEY idx_media_type_status (media_type, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

5.3 orders

CREATE TABLE orders (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(64) NOT NULL,
    user_id BIGINT UNSIGNED NOT NULL,
    media_id BIGINT UNSIGNED NOT NULL,
    amount DECIMAL(10,2) NOT NULL,
    status ENUM('PENDING','PAID','FAILED','REFUNDED','CANCELLED') NOT NULL,
    payment_provider VARCHAR(32) NULL,
    provider_trade_no VARCHAR(128) NULL,
    paid_at DATETIME NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP,

    UNIQUE KEY uk_orders_order_no (order_no),
    KEY idx_orders_user_media_status (user_id, media_id, status),
    KEY idx_orders_media_status (media_id, status),

    CONSTRAINT fk_orders_user
        FOREIGN KEY (user_id) REFERENCES users(id),
    CONSTRAINT fk_orders_media
        FOREIGN KEY (media_id) REFERENCES media(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

5.4 user_media_purchase

CREATE TABLE user_media_purchase (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT UNSIGNED NOT NULL,
    media_id BIGINT UNSIGNED NOT NULL,
    source_order_id BIGINT UNSIGNED NOT NULL,
    access_status ENUM('ACTIVE','REVOKED','REFUNDED')
        NOT NULL DEFAULT 'ACTIVE',
    purchased_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP,

    UNIQUE KEY uk_purchase_user_media (user_id, media_id),
    KEY idx_purchase_media_status (media_id, access_status),

    CONSTRAINT fk_purchase_user
        FOREIGN KEY (user_id) REFERENCES users(id),
    CONSTRAINT fk_purchase_media
        FOREIGN KEY (media_id) REFERENCES media(id),
    CONSTRAINT fk_purchase_order
        FOREIGN KEY (source_order_id) REFERENCES orders(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

高频权限查询:

SELECT 1
FROM user_media_purchase
WHERE user_id = ?
  AND media_id = ?
  AND access_status = 'ACTIVE'
LIMIT 1;

5.5 aes_key_table

CREATE TABLE aes_key_table (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    media_id BIGINT UNSIGNED NOT NULL,
    key_ciphertext VARBINARY(512) NOT NULL,
    key_iv VARBINARY(32) NULL,
    key_version INT NOT NULL DEFAULT 1,
    usage_type ENUM('HLS_AES128','RESERVED_IMAGE') NOT NULL,
    status ENUM('ACTIVE','ROTATED','REVOKED') NOT NULL DEFAULT 'ACTIVE',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP,

    UNIQUE KEY uk_aes_media_version (media_id, key_version),
    KEY idx_aes_media_status (media_id, status),

    CONSTRAINT fk_aes_media
        FOREIGN KEY (media_id) REFERENCES media(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

AES-128 HLS Key 本身是 16 字节。数据库不应保存明文 Key;key_ciphertext 保存由应用主密钥或 AWS KMS 加密后的密文。

如果业务要求“每个媒体都生成 AES Key”,图片也可产生 RESERVED_IMAGE 记录,但普通浏览器图片展示不使用 HLS AES-128;高清图片仍使用 S3 私有对象 + Presigned URL。


6. 索引设计

关键联合索引:

KEY idx_orders_user_media_status (user_id, media_id, status)

用于快速定位某用户对某媒体的订单记录。

UNIQUE KEY uk_purchase_user_media (user_id, media_id)

用于快速判断当前是否拥有媒体访问权。

KEY idx_aes_media_status (media_id, status)

用于快速读取某媒体当前 ACTIVE AES Key。

ordersuser_media_purchase 同时存在的目的:

  • orders 是交易历史;
  • user_media_purchase 是授权状态;
  • 重复下单和失败订单允许保留;
  • 播放/下载时无需在大量历史订单里反复查找;
  • 退款时只需要将授权改为 REFUNDED/REVOKED

7. Redis 设计

7.1 JWT Token

Key:   auth:jwt:<jti>
Value: user_id / session metadata
TTL:   21600 seconds

JWT 建议包含:

{
  "sub": 10001,
  "jti": "random-jwt-id",
  "type": "access",
  "exp": "6小时后"
}

Gin 鉴权顺序:

  1. 校验 JWT 签名;
  2. 校验 exp
  3. 读取 Redis auth:jwt:<jti>
  4. Redis 不存在即认为用户已退出或会话被撤销。

7.2 视频 Play Token

Key:   video:play:<token>
Value: {"user_id":10001,"media_id":123,"permission":"PLAY"}
TTL:   21600 seconds

JWT 表示“用户是谁”,Play Token 表示“这个用户现在被授权播放哪个视频”。


8. 页面加载流程

flowchart TD
    A[Vue 打开媒体详情页] --> B[GET /api/v1/media/:id]
    B --> C[Gin 读取 media]
    C --> D{用户存在 ACTIVE 授权?}
    D -- 否 --> E[返回预览资源 purchased=false]
    D -- 是 --> F[返回预览资源 purchased=true]
    E --> G[缩略图 / 5~10秒预览]
    F --> H[显示高清/完整播放按钮]

详情接口不要提前返回完整视频地址、高清图片地址或 AES Key。


9. 支付成功流程

sequenceDiagram
    participant V as Vue3
    participant G as Gin
    participant P as 支付平台
    participant M as MySQL
    V->>G: 创建订单
    G->>M: orders=PENDING
    V->>P: 完成支付
    P->>G: Webhook
    G->>P: 验签/金额/订单号
    G->>M: orders=PAID
    G->>M: user_media_purchase=ACTIVE
    V->>G: 支付后跳转/刷新详情页
    G-->>V: purchased=true

前端支付回调不能直接赋予访问权限。必须以服务端 Webhook 验证并写入 MySQL 后的状态为准。


10. 图片资源流程

10.1 未购买

flowchart LR
    A[Vue] --> B[媒体详情API]
    B --> C[短期 S3 预览 Presigned URL]
    C --> D[img 显示]
    D --> E[禁止右键/拖拽]

Vue:

<img :src="previewUrl" @contextmenu.prevent draggable="false" />

右键限制只是 UI 限制,预览图必须本身就是低清/水印版本。

10.2 已购买高清图

sequenceDiagram
    participant V as Vue3
    participant G as Gin
    participant M as MySQL
    participant S as AWS S3
    V->>G: POST /media/123/image-access
    G->>M: 查询 user_media_purchase
    M-->>G: ACTIVE
    G->>S: Presign GetObject
    G-->>V: 短期 imageUrl/downloadUrl
    V->>S: GET Presigned URL
    S-->>V: 高清图片

11. 视频预览

未购买用户只播放单独生成的 5~10 秒预览文件:

preview/video/<media-id>/preview-8s.mp4

预览 MP4 与完整 HLS 分离。

<video controls controlsList="nodownload" @contextmenu.prevent></video>

12. HLS + AES-128

完整视频转码后:

master.m3u8
index_1080p.m3u8
seg_00001.ts
seg_00002.ts
...

m3u8 中:

#EXT-X-KEY:METHOD=AES-128,URI="/api/v1/videos/123/key?play_token=..."

12.1 前端不手工 AES 解密

正确职责:

hls.js
  -> 请求 m3u8
  -> 请求 AES Key
  -> 请求加密 .ts
  -> hls.js/浏览器媒体栈完成 AES-128 解密
  -> <video> 播放

13. 已购买视频完整播放流程

sequenceDiagram
    participant V as Vue3+hls.js
    participant G as Gin
    participant M as MySQL
    participant R as Redis
    participant S as S3 Accelerate
    V->>G: POST /videos/123/play + JWT
    G->>M: 查询购买授权
    M-->>G: ACTIVE
    G->>R: SET PlayToken EX 21600
    G-->>V: playToken + manifestUrl
    V->>G: GET manifest?play_token=TOKEN
    G->>R: 校验 PlayToken
    G->>S: 读取 m3u8
    G-->>V: 重写后的 m3u8
    V->>G: GET key?play_token=TOKEN
    G->>R: 校验 Token
    G->>M: 读取 AES Key
    G-->>V: 16-byte Key
    loop 每个 .ts
        V->>G: GET segment/N?play_token=TOKEN
        G->>R: 校验 Token
        alt 有效
            G-->>V: 302 -> S3 Presigned URL
            V->>S: GET segment.ts
            S-->>V: AES 加密分片
        else 过期
            G-->>V: PLAY_TOKEN_EXPIRED
        end
    end

13.1 Gin 鉴权 + 302 的原因

S3 无法读取 Redis 中的 Play Token,所以分片先访问 Gin:

Browser -> Gin Segment API
             |
             +-- Redis 校验 Play Token
             |
             `-- 302 Location: S3短期Presigned URL

Browser -> S3 -> 获取真实.ts数据

这样 Gin 不承担视频字节带宽。


14. m3u8 动态重写

Gin 返回给 hls.js 的内容类似:

#EXT-X-KEY:METHOD=AES-128,URI="https://api.example.com/api/v1/videos/123/key?play_token=TOKEN"

#EXTINF:6.000,
https://api.example.com/api/v1/videos/123/segments/00001?play_token=TOKEN

#EXTINF:6.000,
https://api.example.com/api/v1/videos/123/segments/00002?play_token=TOKEN

Query 中的 Token 不应写入普通访问日志或第三方分析日志。


15. Play Token 过期

每个分片都校验:

Redis GET video:play:<token>

不存在                -> PLAY_TOKEN_EXPIRED
media_id 不匹配       -> FORBIDDEN
存在且匹配            -> 302 S3 Presigned URL

返回:

{
  "code": "PLAY_TOKEN_EXPIRED",
  "message": "视频播放令牌已过期,请刷新页面后重新播放。"
}

前端:

  1. 停止播放器;
  2. 销毁 hls.js;
  3. 显示过期提示;
  4. 不自动续签;
  5. 用户刷新页面;
  6. 再次点击播放;
  7. 创建新 Token;
  8. 从头播放。

16. Gin 中间件架构

flowchart TD
    A[Request] --> B[Recovery / Logger / CORS]
    B --> C{需要登录?}
    C -- 是 --> D[JWT Middleware]
    D --> E[签名 + exp + Redis jti]
    E --> F{视频资源?}
    F -- 是 --> G[PlayToken Guard]
    G --> H[Redis校验 user/media/TTL]
    H --> I[Handler]
    F -- 否 --> I
    C -- 否 --> I

推荐目录:

server/
|-- cmd/api/
|-- internal/
|   |-- handler/
|   |-- middleware/
|   |-- service/
|   |-- repository/
|   |-- model/
|   |-- cache/
|   |-- storage/s3/
|   |-- crypto/
|   `-- config/
`-- migrations/

17. Vue 3 模块

src/
|-- api/
|   |-- auth.js
|   |-- media.js
|   |-- order.js
|   `-- video.js
|-- views/
|   |-- MediaDetail.vue
|   `-- PaymentResult.vue
|-- components/
|   |-- ImagePreview.vue
|   |-- VideoPreview.vue
|   `-- PaidVideoPlayer.vue
|-- stores/auth.js
`-- utils/hls-player.js

18. API 设计

Method Path 说明
POST /api/v1/auth/login 登录,JWT Redis TTL 6h
POST /api/v1/auth/logout 删除 JWT 会话
GET /api/v1/media/:id 媒体详情、预览、purchased
POST /api/v1/media/:id/image-access 高清图片地址
POST /api/v1/orders 创建订单
GET /api/v1/orders/:orderNo/status 订单状态
POST /api/v1/payment/webhook 支付回调
POST /api/v1/videos/:id/play 创建 Play Token
GET /api/v1/videos/:id/manifest 返回重写 m3u8
GET /api/v1/videos/:id/key 返回 AES Key
GET /api/v1/videos/:id/segments/:seq 校验 Token 后 302 S3

19. AES Key 生命周期

  1. 生成随机 16-byte AES Key;
  2. 生成 IV;
  3. 使用主密钥/AWS KMS 加密;
  4. 密文保存 aes_key_table
  5. FFmpeg 使用明文 Key 做 HLS AES-128;
  6. 明文 Key 不写日志。

播放时:

Key API
 -> Play Token校验
 -> 查询ACTIVE Key
 -> 服务端解密key_ciphertext
 -> 返回16-byte Key

后续可以通过 key_version 做 Key Rotation。


20. Token 与 Presigned URL 的 TTL

JWT Token:        6小时
Play Token:       6小时
高清图片URL:      建议数分钟
Segment S3 URL:   建议1~5分钟

业务 Token 与 S3 对象签名 URL 是两个不同的权限层。


21. 错误码

HTTP code 场景
401 JWT_EXPIRED JWT 过期
401/419 PLAY_TOKEN_EXPIRED Play Token 过期
403 MEDIA_NOT_PURCHASED 未购买
403 PLAY_TOKEN_MEDIA_MISMATCH Token 与媒体不匹配
404 MEDIA_NOT_FOUND 媒体不存在
409 ORDER_STATE_CONFLICT 订单状态冲突
500 S3_PRESIGN_FAILED S3 签名失败
500 AES_KEY_LOAD_FAILED AES Key 读取/解密失败

22. 安全边界

风险 对策
未购买访问原图 S3 私有 + 购买鉴权 + Presigned URL
未购买访问完整视频 /play 前查询授权
分享 m3u8 Play Token 绑定 user/media
分享分片 Segment API 每次查 Redis
直接访问 AES Key Key API 强制 Play Token
JWT 被注销后继续使用 删除 Redis JWT 会话
前端篡改 purchased 高清/播放 API 再次服务端鉴权
固定 S3 URL 泄露 私有对象 + 短时 Presigned URL
AES Key 表泄露 Key 保存密文
日志泄露 Token/URL/Key 脱敏

23. 思维导图

mindmap
  root((付费媒体站))
    用户
      JWT
      Redis 6h
    媒体
      图片预览
      高清原图
      短视频预览
      HLS完整视频
    支付
      orders
      Webhook
      user_media_purchase
    视频安全
      Play Token
      AES-128
      Key API
      分片鉴权
      302 S3
    存储
      S3 Private
      Transfer Acceleration

24. 端到端状态机

stateDiagram-v2
    [*] --> Preview
    Preview --> Paying: 点击购买
    Paying --> Preview: 支付失败
    Paying --> Paid: 服务端确认
    Paid --> Authorized: 点击完整播放
    Authorized --> Playing: Token有效
    Playing --> Playing: 下一个分片
    Playing --> TokenExpired: Token过期
    TokenExpired --> Preview: 刷新
    Preview --> Paid: 重新查询已购

25. 开发顺序

  1. MySQL 五表与 migration。
  2. JWT + Redis 6h。
  3. 媒体详情和预览。
  4. 订单与支付 Webhook。
  5. user_media_purchase
  6. 高清图片 Presigned URL。
  7. FFmpeg HLS AES-128 转码。
  8. Play Token Redis 6h。
  9. m3u8 重写。
  10. AES Key API。
  11. Segment 鉴权 + 302 S3。
  12. Vue + hls.js。
  13. Token 过期提示与从头播放。
  14. 日志脱敏、限流、监控、压测。

26. 验收清单

  • [ ] 未购买只能看到图片缩略图和几秒视频。
  • [ ] 图片/视频禁止普通右键下载。
  • [ ] 未购买高清接口返回 403。
  • [ ] 未购买 /play 返回 403。
  • [ ] 支付成功后 orders=PAID
  • [ ] 支付成功后 user_media_purchase=ACTIVE
  • [ ] 刷新页面返回 purchased=true
  • [ ] JWT Redis TTL=21600。
  • [ ] Play Token Redis TTL=21600。
  • [ ] m3u8 Key/Segment URL 经 Gin 重写。
  • [ ] Key API 必须校验 Play Token。
  • [ ] 每个 Segment 都校验 Play Token。
  • [ ] Segment 鉴权成功 302 到 S3 Accelerate。
  • [ ] Token 过期停止播放并提示刷新。
  • [ ] 刷新后重新申请 Token,从头播放。
  • [ ] S3 原图/HLS/分片均不能匿名读取。
  • [ ] AES Key 数据库中不保存明文。
  • [ ] 日志中不记录完整 JWT/PlayToken/Presigned URL/AES Key。

27. 最终链路

Vue3
  |
  | JWT
  v
Gin API ------------------------- MySQL
  |                                 users
  |                                 media
  |                                 orders
  |                                 user_media_purchase
  |                                 aes_key_table
  |
  +------------------------------ Redis
  |                                 JWT TTL 6h
  |                                 Play Token TTL 6h
  |
  +-- 图片: 鉴权 -> S3 Presigned URL
  |
  `-- 视频:
       /play -> 购买鉴权 -> Play Token
       /manifest -> 重写m3u8
       /key -> AES Key
       /segment -> Token校验 -> 302 S3
                                       |
                                       v
                                 AWS S3 Private Bucket
                                 s3-accelerate endpoint


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言